In this tutorial, we’ll explore how we can improve the interface of an Active Record Model by extracting existing logic into value objects by using Rails’ composed_of macro.
Setup
Below is our starting point. We have a Run
model that has duration
,
distance
and unit
attributes.
# /db/migrate/[timestamp]_create_runs.rb
class CreateRuns < ActiveRecord::Migration[7.0]
def change
create_table :runs do |t|
t.interval :duration
t.decimal :distance, null: false
t.string :unit, null: false
t.timestamps
end
end
end
It also has a convert_to
method powered by ruby-units that converts the
run’s distance into another unit of measurement.
# /app/models/run.rb
class Run < ApplicationRecord
validates :unit, inclusion: { in: %w(mi m km) }
def convert_to(new_unit)
Unit.new("#{distance} #{unit}").convert_to(new_unit)
end
end
run = Run.new(distance: 5, unit: "km")
run.convert_to("mi")
=> 3.10686 mi
Limitations with existing interface
Although the convert_to
method returns a new Unit
instance, our Run
model
does not offer a simple way to interface with the instance of that Unit
.
For example, if we want to compare distances of varying units, we need to do something like this:
unit_5_km = Run.new(distance: 5, unit: "km").convert_to("km")
unit_3_mi = Run.new(distance: 3, unit: "mi").convert_to("mi")
unit_5_km > unit_3_mi
# => true
It would be preferable if we could call a method directly on Run
to return a
new Unit
instance without needing to pass a redundant argument.
Improving our interface with composed_of
Fortunately Rails provides an API for representing attributes as value objects via the composed_of macro.
Adding a measurement attribute
Let’s improve our interface by adding Run#measurement
which will return a new
Unit
instance.
class Run < ApplicationRecord
+ composed_of :measurement,
+ class_name: "Unit",
+ mapping: [ %w(distance scalar), %w(unit units) ]
+
validates :unit, inclusion: { in: %w(mi m km) }
def convert_to(new_unit)
We need to set the class_name
to "Unit"
since the class name cannot be
inferred via the :measurement
attribute. By default, Rails would have looked
for a Measurement
class.
We also set the mapping
so that the value of distance
is set as the scalar
value on the Unit
instance, and the value of unit
is set as the units
value on the Unit
instance. You can think of it like this:
run = Run.new(distance: 5, unit: "km", duration: 15.minutes)
unit = Unit.new(run.distance, run.unit)
# => 5 km
unit.scalar
# => 5
unit.units
# => "km"
With this change, we now have access to the Unit
class via the measurement
attribute. Prior to this commit, Run#convert_to
was the only way to interact
with the Unit
instance. Now we can call Run#measurement
.
run = Run.new(distance: 5, unit: "km")
run.measurement
# => 5 km
run.measurement.scalar
# => 5
run.measurement.units
# => "km"
run.measurement > Run.new(distance: 3, unit: "mi").measurement
# => true
Now that we have a value object to work with, we can refactor our convert_to
method.
validates :unit, inclusion: { in: %w(mi m km) }
def convert_to(new_unit)
- Unit.new("#{distance} #{unit}").convert_to(new_unit)
+ measurement.convert_to(new_unit)
end
end
Allow measurement to be set via a string
The composed_of macro also allows us to set the measurement
attribute (and
therefor the distance
and unit
attributes) like so:
run = Run.new
run.measurement = Unit.new("1 mi")
run.distance
# => 1
run.unit
# => "mi"
However, we can improve this interface by using the converter
option. The
converter
option takes the value passed to the measurement
attribute and
calls a Proc to correctly initialize the Unit
class.
class Run < ApplicationRecord
composed_of :measurement,
class_name: "Unit",
- mapping: [ %w(distance scalar), %w(unit units) ]
+ mapping: [ %w(distance scalar), %w(unit units) ],
+ converter: Proc.new { |value| Unit.new(value) }
validates :unit, inclusion: { in: %w(mi m km) }
Now we can do something like this:
run = Run.new
run.measurement = "1 mi"
run.distance
# => 1
run.unit
# => "mi"
Query by measurement
The composed_of
macro also allows us to query by the attribute with where
or find_by.
mile = Run.create(distance: 1, unit: "mi")
Run.where(measurement: Unit.new("1 mi"))
# => #<ActiveRecord::Relation [#<Run>]>
Run.find_by(measurement: Unit.new("1 mi"))
# => #<Run>
However, there is a limitation with this API. It creates a naive WHERE
clause
based on the attributes used in the mapping
. The example below demonstrates
how two run
records with the same converted distance need to be queried
separately.
mile = Run.create(distance: 1, unit: "mi")
mile_in_meters = Run.create(distance: 1609.344, unit: "m")
Run.where(measurement: Unit.new("1 mi")).count
# => 1
Run.where(measurement: Unit.new("1609.344 m")).count
# => 1
puts Run.where(measurement: Unit.new("1 m")).to_sql
# => SELECT "runs".* FROM "runs" WHERE "runs"."distance" = 1 AND "runs"."unit" = 'm'
puts Run.where(measurement: Unit.new("1609.344 m")).to_sql
# => SELECT "runs".* FROM "runs" WHERE "runs"."distance" = 1609.344 AND "runs"."unit" = 'm'
Calculating pace
Now that we have a basic understanding of how compose_of
works, let’s extend
our Run
class by adding the ability to calculate pace.
def convert_to(new_unit)
measurement.convert_to(new_unit)
end
+
+ def pace(measurement = "mi")
+ unit = Unit.new(measurement)
+ interval = (self.convert_to(unit).scalar.to_f / unit.scalar.to_f)
+ split = duration.in_seconds / interval
+ duration = formatted_time(split)
+
+ if unit.scalar == 1
+ "#{duration} per #{unit.units}"
+ else
+ "#{duration} per #{unit}"
+ end
+ end
+
+ private
+
+ def formatted_time(total_seconds)
+ total_seconds = total_seconds.round
+ minutes = total_seconds / 60
+ seconds = total_seconds % 60
+
+ "#{minutes}:#{seconds.to_s.rjust(2, '0')}"
+ end
end
run = Run.new(duration: 14.minutes + 41.seconds, measurement: "5 km")
run.pace
# => "4:44 per mi"
run.pace("km")
# => "2:56 per km"
run.pace("3 km")
# => "8:49 per 3km"
This is a good start, but it would be better if we returned an object instead of
a String
. Doing so would allow us to operate against the duration
and unit
individually, as well as compare paces from different runs, even if they’re
using different units of measurement.
Introduce pace attribute
We can refactor the previous implementation by once again leveraging composed_of.
class_name: "Unit",
mapping: [ %w(distance scalar), %w(unit units) ],
converter: Proc.new { |value| Unit.new(value) }
+ composed_of :pace,
+ mapping: [ %w(duration duration), %w(distance distance), %w(unit unit)],
+ constructor: Proc.new { |duration, distance, unit| Pace.new(duration: duration, distance: distance, unit: unit) }
validates :unit, inclusion: { in: %w(mi m km) }
- def pace(measurement = "mi")
- unit = Unit.new(measurement)
- interval = (self.convert_to(unit).scalar.to_f / unit.scalar.to_f)
- split = duration.in_seconds / interval
- duration = formatted_time(split)
-
- if unit.scalar == 1
- "#{duration} per #{unit.units}"
- else
- "#{duration} per #{unit}"
- end
- end
-
- private
-
- def formatted_time(total_seconds)
- total_seconds = total_seconds.round
- minutes = total_seconds / 60
- seconds = total_seconds % 60
-
- "#{minutes}:#{seconds.to_s.rjust(2, '0')}"
- end
end
Below is our new Pace
class, which provides a richer interface when compared
to the previous implementation which just returned a String
.
# app/models/pace.rb
class Pace
include Comparable
attr_reader :duration, :distance, :unit, :measurement
def initialize(duration:, distance:, unit:)
@duration = duration
@distance = distance
@unit = unit
@measurement = Unit.new(distance, unit)
end
def split(measurement = "1 mi")
unit = Unit.new(measurement)
interval = (self.measurement.convert_to(unit).scalar.to_f / unit.scalar.to_f)
split = (duration.in_seconds / interval).round
duration = ActiveSupport::Duration.build(split)
Split.new(duration: duration, distance: unit)
end
def >(other)
self.split.duration > other.split.duration
end
def <(other)
self.split.duration < other.split.duration
end
def ==(other)
self.split.duration == other.split.duration
end
class Split
attr_reader :duration, :distance
def initialize(duration:, distance:)
@duration = duration
@distance = distance
end
def inspect
calculate
end
def to_s
calculate
end
private
def calculate
unit = Unit.new(distance)
parsed_duration = formatted_time(duration)
if unit.scalar == 1
"#{parsed_duration} per #{unit.units}"
else
"#{parsed_duration} per #{unit}"
end
end
def formatted_time(total_seconds)
total_seconds = total_seconds.round
minutes = total_seconds / 60
seconds = total_seconds % 60
"#{minutes}:#{seconds.to_s.rjust(2, '0')}"
end
end
end
Now we can calculate the pace off of the pace
attribute instead.
run = Run.new(duration: 14.minutes + 41.seconds, measurement: "5 km")
run.pace.split
# => 4:44 per mi
run.pace.split("km")
# => 2:56 per km
run.pace.split("3 km")
# => 8:49 per 3km
The advantage to this approach is that we can now compare paces, which was not possible with the previous implementation.
run_5_km = Run.new(duration: 15.minutes, measurement: "5 km")
run_1_mi = Run.new(duration: 5.minutes, measurement: "1 mi")
run_5_km.pace < run_1_mi.pace
# => true
You’ll also note that we used the constructor
option when declaring our pace
attribute. Without this option, we would raise the following error:
ArgumentError: wrong number of arguments (given 3, expected 0; required keywords: duration, distance, unit)
This is because our Pace
class uses keyword arguments and not positional
arguments.
Allow pace to be set by a string
Similar to the measurement
attribute, setting the pace
attribute will
populate the duration
, distance
, unit
, and even the measurement
attributes.
run = Run.new
run.pace = Pace.new(duration: 15.minutes, distance: "5", unit: "km")
run.duration
# => 15 minutes
run.distance
# => 5
run.unit
# => "km"
run.measurement
# => 5 km
However, we can improve our interface by allowing the pace
to be set by a
Hash
by adding the converter
option.
converter: Proc.new { |value| Unit.new(value) }
composed_of :pace,
mapping: [ %w(duration duration), %w(distance distance), %w(unit unit)],
- constructor: Proc.new { |duration, distance, unit| Pace.new(duration: duration, distance: distance, unit: unit) }
+ constructor: Proc.new { |duration, distance, unit| Pace.new(duration: duration, distance: distance, unit: unit) },
+ converter: Proc.new { |hash|
+ hash = hash.symbolize_keys
+ unit = Unit.new(hash[:unit])
+ measurement = Unit.new(hash[:measurement])
+ minutes, seconds = hash[:duration].split(":").map(&:to_i)
+ total_duration_in_seconds = ((minutes * 60 + seconds) * measurement.convert_to(unit).scalar).floor
+ duration = ActiveSupport::Duration.build(total_duration_in_seconds)
+
+ Pace.new(duration: duration, distance: measurement.scalar, unit: measurement.units)
+ }
validates :unit, inclusion: { in: %w(mi m km) }
Now instead of passing a new instance of a Pace
, we can pass something more
useful that actually encodes the pace directly.
run = Run.new
run.pace = {duration: "4:50", unit: "1 mi", measurement: "5 km"}
run.duration
# => 15 minutes
run.distance
# => 5
run.unit
# => "km"
run.measurement
# => 5 km